Handling macOS URL schemes with Go
For a while I’ve had this idea of a custom browser handler in macOS that would
have configurable rules for determining which browser a URL should open. I
finally got around to building it which led to a lot of learning of what to-do
and what not to-do when it comes to Cocoa apps and Go interop. If that sounds
interesting you can find the code on GitHub, otherwise this post
goes over how the core functionality of the application, macOS http
/https
URL scheme handling was written.
Initial Approaches
This project had a really bumpy start. The first task was to get a .app
calling an executable. After getting this working, I soon realized that the url
isn’t passed via STDIN
, and it’s not available in os.Args
. Turns out, macOS
passes it via an event manager which Go doesn’t have access to.
The next approach was to define a simple AppleScript that would listen for a URL open event, then call the executable passing the URL. This seemed promising but sadly I couldn’t get it working.
With both of those options not panning out, there was clearly one choice left. Writing some Objective-C.
Objective-C and cgo
Thankfully Go has amazing support for C interop via cgo. Not only was using Objective-C possible, but it has strong support too. This meant that the Objective-C strategy was good to go (pun intended, sorry not sorry).
After a lot of trail and error, this is a minimal implementation that can actually listen for, and handle URL events.
To get started, first we need to define our Go code.
// main.go
package main
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
#include "browse.h"
*/
import "C"
import (
"os/exec"
)
var urlListener chan string = make(chan string)
func main() {
go C.RunApp()
url := <-urlListener
// replace with implementation
cmd := exec.Command("open", "-a", "Safari", url)
cmd.Run()
}
//export HandleURL
func HandleURL(u *C.char) {
urlListener <- C.GoString(u)
}
This is pretty straightforward, we’re defining the main package, then using cgo
to tell the compiler to pass the CFLAGS -x objective-c
telling it that we’re
compiling Objective-C code. We’re also passing LDFLAGS
which is telling the
linker that we want to link the Cocoa framework. Finally, we import the header
file that we’ll see in just a second. This is where our Objective-C code will
go.
We also define a function that’s exported to C, HandleURL
. The //export
HandleURL
directive above tells the compiler to make this function globally
available in our C code. It’s also worth noting that the arguments it receives
are from C so we end up receiving a C string which then has to be converted to a
Go string via C.GoString
.
The Objective-C pieces come in two parts, the header file and the source file.
// browse.h
#import <Cocoa/Cocoa.h>
extern void HandleURL(char*);
@interface BrowseAppDelegate: NSObject<NSApplicationDelegate>
- (void)handleGetURLEvent:(NSAppleEventDescriptor *) event withReplyEvent:(NSAppleEventDescriptor *)replyEvent;
@end
void RunApp();
The header file is pretty straightforward. We import Cocoa since this is
technically going to be a Cocoa application. We define the exported Go function
HandleURL
as an external function that accepts a string and returns nothing so
we’re able to call it from Objective-C.
Next we have to define an NSApplicationDelegate
subclass. This is an object
that defines lifecycle event methods that a Cocoa application will call. We
have to implement some of the callbacks in order to hook up our event listener.
We also define a method of our own, handleGetUrlEvent:withReplyEvent
that
we’ll define and use in just a second to receive URL events.
Finally, we define another function RunApp
that will be called via Go to start
the Cocoa application.
// browse.m
#include "browse.h"
@implementation BrowseAppDelegate
- (void)applicationWillFinishLaunching:(NSNotification *)aNotification
{
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:self
andSelector:@selector(handleGetURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass andEventID:kAEGetURL];
}
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
HandleURL((char*)[[[event paramDescriptorForKeyword:keyDirectObject] stringValue] UTF8String]);
}
@end
void RunApp() {
[NSAutoreleasePool new];
[NSApplication sharedApplication];
BrowseAppDelegate *app = [BrowseAppDelegate alloc];
[NSApp setDelegate:app];
[NSApp run];
}
There’s a lot of code here, but it’s largely boilerplate. We define the
implementation for our BrowseAppDelegate
and implement a
NSApplicationDelegate
callback, applicationWillFinishLaunching
. We use this
to get the shared event manager. Now that we have the event manager, we can add
an event handler for URL open events that calls the
handleGetURLEvent:withReplyEvent
method we declared in our interface and
define below.
In handleGetURLEvent:withReplyEvent
we get the string value from the event,
cast it from an NSString
to a C string via UTF8String
. We then need to cast
that new C string to char*
to prevent a compiler warning, but it’s not
necessary for the code to compile or run.
Lastly, we have our RunApp
function. This calls the necessary boilerplate
methods for a Cocoa app, allocates memory for a new BrowseAppDelegate
, sets it
as the application’s delegate so our callbacks will be called, and tells the
application to run.
We can make sure the code compiles by running go build
.
Whew, that’s a lot of code just to get a single URL. Sadly, this still isn’t
useful on its own. For the app to work we need to package the executable in a
.app
. Fortunately, this is mostly just more boilerplate.
Run the following in a terminal to create the .app
along with the compiled
application.
mkdir -p Browse.app/Contents/MacOS
go build -o Browse.app/Contents/MacOS/Browse
Last but not least, we need to create the plist. This defines metadata about the
application including that we can handle http
/https
url’s.
To get the .app
to register with macOS as a browser/URL handler, you can paste
the following code into a new file, Browse.app/Contents/Info.plist
.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Browse</string>
<key>CFBundleExecutable</key>
<string>Browse</string>
<key>CFBundleIdentifier</key>
<string>com.blakeorwhatever.browse</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Browse</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Web site URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>FTP site URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ftp</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>Local file URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>file</string>
</array>
</dict>
</array>
</dict>
</plist>
Now you should have a completely working macOS app! With that, we can drag and
drop Browse.app
into the Applications
folder. This should register
Browse.app
as a browser and we can set it as the default browser in System
Preferences
-> General
-> Default web browser
.
Now each time you open a URL, your Go application handles the URL and should
open Safari. It doesn’t add any new functionality, but this opens a whole world
of possibilities for handling URL’s in macOS. It’s also worth noting that you
can define your own URL schemes or handle URL schemes besides just http
and
https
.